Authz refactor w/ orthogonal capabilities#2936
Conversation
| if required.is_empty() { | ||
| debug_assert!( | ||
| false, | ||
| "is_authorized called with empty orthogonal capabilities" | ||
| ); | ||
| return false; | ||
| } |
There was a problem hiding this comment.
asking is_authorized()? with empty capabilities returns false
9c4d16d to
4ebba92
Compare
| let (rg, ug, uid) = build_orthogonal_scenario( | ||
| vec![("acmeCo/", vec![Write, Assume])], | ||
| vec![("acmeCo/", "bobCo/shared/", vec![Read, Billing, TeamAdmin])], | ||
| ); | ||
| assert_authorized(&rg, &ug, uid, "acmeCo/", vec![Write]); | ||
| assert_not_authorized(&rg, &ug, uid, "bobCo/shared/", vec![Write]); |
There was a problem hiding this comment.
note potentially non-obvious behavior
Adds an orthogonal capability system alongside the existing hierarchical (read/write/admin) authorization model. Both RoleGrant::is_authorized and UserGrant::is_authorized now accept `impl Into<AnyCapability>`, dispatching to either the legacy BFS (transitive_roles/GrantRef) or the new orthogonal BFS (reachable_nodes/NodeRef).
7aa86fb to
1d60747
Compare
| let env = ctx.data::<crate::Envelope>()?; | ||
| let claims = env.claims()?; | ||
|
|
||
| if capability == models::Capability::None { |
There was a problem hiding this comment.
I introduced the None capability in order to allow us to represent e.g a Billing-only user grant, but we shouldn't let people actually create invite links with that capability. This is unlikely to ever be an issue, and we're going to be refactoring this API to support bundles soon, but just in case.
7fda120 to
4393762
Compare
|
Alright @GregorShear take a look at the most recent commit. A few worthy call-outs: This leaves all existing authorization checks aloneEvery existing authorization call-site stays exactly as it was. This means the commit doesn't migrate any enforcement site to fine-grained bits. That's intentional: a future PR can deliberately design new bits and move specific gates onto them (e.g.,
|
Authorization grants confer a set of named bundles (Viewer, Writer, Editor, Admin, Billing, TeamAdmin, Delegate, Assume) stored in a new `bundles` array column. Application code maps each bundle to a set of fine-grained capability bits, and authorization checks operate on those bits: * The bundle-to-bits mapping is an application-layer concern, so new capability bits can be introduced or recomposed in code without a DB migration. * A single `reachable_nodes` BFS evaluates authorization over `authz::CapabilitySet`; `Delegate` and `Assume` bits express transitivity explicitly, rather than hard-coding propagation when `capability == Admin`. * `Capability::None` (renamed from the placeholder `x_00`) supports grants whose authorization comes entirely from the bundles column, so roles like `Billing` can be granted without leaking `read` access through RLS. * A new `OrthogonalCapability` GraphQL enum and `PrefixRef.capabilities` field expose the fine-grained bits to clients, allowing future feature gates to check specific capabilities rather than coarse legacy levels. * The legacy `capability` column continues to drive RLS unchanged; `bits_for_legacy` converts existing `read`/`write`/`admin` values to bundle-equivalent bit sets so existing authorization callers compile and behave identically for current grants.
Summary
Adds an orthogonal capability model that coexists with the legacy
read/write/adminhierarchy. Existing authorization paths (PostgREST, transitive-roles BFS) are untouched. New GraphQL authorization checks can opt into orthogonal capabilities, and we'll migrate existing GraphQL checks over one at a time. Once the GraphQL API covers everything PostgREST does, PostgREST and the legacy capability path can be retired together.What changes
A new
OrthogonalCapabilityenum androle_grants.capabilities/user_grants.capabilitiescolumns let a grant carry an independent set of capabilities, rather than a single level in a hierarchy. This is finer-grained than the legacy roles: instead ofadminimplying everything, a grant lists exactly which capabilities it confers.Special capabilities:
delegate— a grant carryingdelegatecan propagate its own capabilities to the next hop. The next hop's effective set isnode.capabilities ∩ edge.capabilities— you can only pass on capabilities you actually hold. Withoutdelegate, the capabilities apply at the object, but cannot chain further.assume— a grant carryingassumeis a trust root: the next hop inherits the full capability set declared on the edge, with no intersection against the parent's caps. Used when delegating complete authority (e.g. a user grant that says "this user fully impersonatestenantA/groups/editors/"), and as the BFS seed marker so user_grants get their declared capabilities through unfiltered.In short:
delegatecarries your own permissions forward;assumecarries the edge's permissions forward.Coexistence with legacy
AnyCapabilitywraps either a single legacyCapabilityor aVec<OrthogonalCapability>.RoleGrant::is_authorizedandUserGrant::is_authorizeddispatch on the variant: the legacy arm runs the existingtransitive_rolesBFS unchanged; the orthogonal arm runs a newreachable_nodesBFS that respects thedelegate/assumerules above. Call sites pick which model they want.Migration path
The two systems live side-by-side indefinitely. GraphQL authorization checks get migrated to orthogonal capabilities one at a time as we gain confidence. When the GraphQL API has full coverage of what PostgREST does today, PostgREST is retired and the legacy
capabilitycolumn / BFS can be dropped.Test plan
supabase db resetapplies cleanlycargo sqlx prepare --workspaceis up to datecargo check -p control-plane-apiandcargo test -p tablespassdelegatepropagation,assumetrust-root semantics, terminal nodes, multi-path capability union, andRoleGrantreachability